#!/usr/bin/env python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

# Copyright (c) 2019-2020 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated DocTemplation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


import os
import io
import PIL
import cv2
import time
import numpy
import selenium
import selenium.webdriver
import threading
from selenium.webdriver.common.by import By
from .manpa_util import ManpaUtil, SeleniumUtil
from .manpa_downloader import ManpaDownloadItemGeneralFile
from .manpa_exception import InterceptionDetectedException


class ManpaSeleniumWebDriver:

    def __init__(self, parent):
        self._parent = parent
        self._parent._seleniumClientList.append(self)

        self._driver = None
        self._recordThread = None
        self._curTabIndex = None
        try:
            # select User-Agent
            ua = ManpaUtil.getRandomUserAgent()

            # select chromium options
            options = []
            if True:
                options = selenium.webdriver.chrome.options.Options()
                options.add_argument('--no-sandbox')                                            # FIXME
                options.add_argument('--user-data-dir=%s' % (self._parent._tmpUserDataDir))
                options.add_argument('--ignore-ssl-error=yes')
                options.add_argument('--ignore-certificate-errors')                             # FIXME
                options.add_argument('--disable-popup-blocking')
                options.add_argument('--no-default-browser-check')
                options.add_argument('--window-size=%d,%d' % (self._parent._width, self._parent._height))       # non-debug model doesn't need it by using xvfb resolution
                if ua is not None:
                    options.add_argument('--user-agent=' + ua)
                if len(self._parent._proxyList) > 0:
                    options.add_argument('--proxy-server=' + self._parent._proxyList[0])        # FIXME: get random proxy
                if not self._parent._isDebug:
                    options.add_argument('--headless=new')

            # select chromium preferences
            prefs = dict()
            if True:
                # enable download capabilities
                prefs.update({
                    "download.default_directory": self._parent._tmpDownloadDir,
                    "download.prompt_for_download": False,
                    "download.directory_upgrade": True,
                    "safebrowsing.enabled": False,
                    "safebrowsing.disable_download_protection": True,
                })

            # select chromium capabilities
            caps = selenium.webdriver.DesiredCapabilities().CHROME
            # caps["pageLoadStrategy"] = "none"           # do not wait in selenium, so that we can inspect all delays because they are all in the code of ourself

            # create webdriver object
            options.add_experimental_option("prefs", prefs)
            self._driver = selenium.webdriver.Chrome(options=options, desired_capabilities=caps)

            self._recordThread = ManpaSeleniumRecordThread(self._driver, self._parent._videoLogFile)
            self._recordThread.start()

            # open download tab
            self._driver.get("chrome://downloads/")
            for i in ManpaUtil.sleepyZeroToMax(0, "1"):
                downloadManagerElement = self.__getDownloadManagerElement()
                if downloadManagerElement is not None:
                    assert len(self.__getDownloadItemElementList(downloadManagerElement, bWithHidden=True)) == 0
                    break
                if i >= 1:
                    self._parent._warnCallback("Download tab has been loading for %d seconds." % (i))

            # open main tab
            self._driver.execute_script("window.open()")
            self._driver.switch_to.window(self.__getWindowHandles()[-1])

            # init self._curTabIndex
            assert len(self.__getWindowHandles()) == 2
            self._curTabIndex = len(self.__getWindowHandles()) - 1

        except Exception:
            self.dispose()
            raise

    def dispose(self):
        if True:
            self._curTabIndex = None
        if self._recordThread is not None:
            self._recordThread.stop()
            self._recordThread = None
        if self._driver is not None:
            self._driver.quit()
            self._driver = None
        if True:
            self._parent._seleniumClientList.remove(self)
            self._parent = None

    def open_page(self, url):
        for i in ManpaUtil.sleepyZeroToMax(1, "middle"):
            try:
                self.__get(url)
                self.__pageLoadPostProcess()
                break
            except InternalWebDriverRecoverableError as e:
                self._parent._warnCallback("Load URL \"%s\" failed %d time(s) for reason %s." % (url, i, str(e)))

    def open_page_in_new_tab(self, url):
        assert self._curTabIndex == len(self.__getWindowHandles()) - 1    # we must be viewing the last tab

        # open new tab
        self._driver.execute_script("window.open()")
        assert self._curTabIndex == len(self.__getWindowHandles()) - 2     # ensure the new tab is really opened

        # switch to new tab
        self._driver.switch_to.window(self.__getWindowHandles()[-1])
        self._curTabIndex = len(self.__getWindowHandles()) - 1

        # load page
        for i in ManpaUtil.sleepyZeroToMax(1, "middle"):
            try:
                self.__get(url)
                self.__pageLoadPostProcess()
                break
            except InternalWebDriverRecoverableError as e:
                self._parent._warnCallback("Load URL \"%s\" in tab %d failed %d time(s) for reason %s." % (url, self._curTabIndex, i, str(e)))

    def click_for_page(self, elem):
        self._click(elem)

        # post process, reload if neccessary
        for i in ManpaUtil.sleepyZeroToMax(1, "middle"):
            try:
                if i > 1:
                    self._driver.refresh()
                self.__pageLoadPostProcess()
                break
            except InternalWebDriverRecoverableError as e:
                self._parent._warnCallback("Load URL \"%s\" by clicking failed %d time(s) for reason %s." % (self._driver.current_url, i, str(e)))

    def click_for_page_in_new_tab(self, elem):
        assert self._curTabIndex == len(self.__getWindowHandles()) - 1        # we must be viewing the last tab

        # for this link, after click browser will: 1. auto open a new tab; 2. switch to it; 3. load the new page
        self._click(elem)

        # after click, it may need some time for the new tab to be opened (for example the <a> tag is backed by javascript)
        for i in ManpaUtil.sleepyZeroToMax(0, "1"):
            if self._curTabIndex != len(self.__getWindowHandles()) - 1:
                break
            if i >= 1:
                self._parent._warnCallback("New tab does not appear after clicking for %d seconds." % (i))

        # change current tab index
        assert self._curTabIndex == len(self.__getWindowHandles()) - 2
        self._curTabIndex = len(self.__getWindowHandles()) - 1

        # post process, reload page if neccessary
        for i in ManpaUtil.sleepyZeroToMax(1, "middle"):
            try:
                if i > 1:
                    self._driver.refresh()
                self.__pageLoadPostProcess()
                break
            except InternalWebDriverRecoverableError as e:
                self._parent._warnCallback("Load URL \"%s\" in tab %d by clicking failed %d time(s) for reason %s." % (self._driver.current_url, self._curTabIndex, i, str(e)))

    def click_for_download_information_or_downloaded_file(self, elem):
        self.__assertCheckBeforeDownload()

        self._click(elem)

        return self.__waitForDownloadUrlOrFile(bAcceptUrl=True)

    def click_for_downloaded_file(self, elem):
        self.__assertCheckBeforeDownload()

        self._click(elem)

        # FIXME: chromium may keep downloading a file forever, at least on version 111.0.5563.110, so we can only rely on external downloader
        ret = self.__waitForDownloadUrlOrFile(bAcceptUrl=True)
        if ret[0]:
            fullfn = os.path.join(self._parent._tmpDownloadDir, ret[2])
            with self._parent.open_downloader() as downloader:
                item = ManpaDownloadItemGeneralFile(url=ret[1], local_file_path=fullfn)
                downloader.start_download([item])
                downloader.get_progress()               # FIXME: check return value
            return fullfn
        else:
            # return fullfn of downloaded file
            return ret[1]

    def close_current_tab(self):
        assert len(self.__getWindowHandles()) > 2
        assert self._curTabIndex == len(self.__getWindowHandles()) - 1        # we must be viewing the last tab

        self._driver.close()
        self._driver.switch_to.window(self.__getWindowHandles()[-1])
        self._curTabIndex = len(self.__getWindowHandles()) - 1

    @property
    def current_tab_index(self):
        return self._curTabIndex - 1

    # deprecated, use open_page() instead
    def get_and_wait(self, url):
        self.open_page(url)

    # deprecated, use click_for_page() instead
    def click_and_wait(self, elem):
        self.click_for_page(elem)

    def mark_identified_element(self, element, message=None):
        # FIXME
        return

    def mark_identified_elements(self, elements, message=None):
        # FIXME
        return

    def mark_error_element(self, element, message=None):
        # FIXME
        return

    def mark_error_elements(self, elements, message=None):
        # FIXME
        return

    def _click(self, elem):
        try:
            if not SeleniumUtil.isElementCompletelyInViewport(self._driver, elem):
                SeleniumUtil.moveElementToCenterOfViewPort(self._driver, elem)
                ManpaUtil.waitRandomTime("middle")
            elem.click()
        except selenium.common.exceptions.ElementClickInterceptedException:
            self._parent._intercepted = True
            raise InterceptionDetectedException()

    # FIXME: perhaps it is not needed anymore since pageLoadStrategy=none
    def __get(self, url):
        try:
            self._driver.get(url)
        except selenium.common.exceptions.TimeoutException:
            raise InternalWebDriverRecoverableError("ERR_TIME_OUT")
        except selenium.common.exceptions.WebDriverException as e:
            if "net::ERR_CONNECTION_CLOSED" in e.msg:
                raise InternalWebDriverRecoverableError("ERR_CONNECTION_CLOSED")                # unknown error: net::ERR_CONNECTION_CLOSED
            if len(self._parent._proxyList) > 0:
                if "net::ERR_PROXY_CONNECTION_FAILED" in e.msg:
                    raise InternalWebDriverRecoverableError("ERR_PROXY_CONNECTION_FAILED")      # unknown error: net::ERR_PROXY_CONNECTION_FAILED
                if "net::ERR_TUNNEL_CONNECTION_FAILED" in e.msg:
                    raise InternalWebDriverRecoverableError("ERR_TUNNEL_CONNECTION_FAILED")     # unknown error: net::ERR_TUNNEL_CONNECTION_FAILED
            raise

    def __pageLoadPostProcess(self):
        # wait for that the page is really loaded
        for i in ManpaUtil.sleepyZeroToMax(0, "1"):
            s = self._driver.execute_script("return document.readyState")
            if s == "complete":
                break
            if i >= 3600 and i % 60 == 0:
                self._parent._warnCallback("Document has not been ready (current readyState: %s) for URL \"%s\" for %d seconds." % (s, self._driver.current_url, i))

        # process errors
        tlist = self._driver.find_elements(by=By.XPATH, value=".//div[@class='error-code']")
        if len(tlist) > 0:
            assert len(tlist) == 1
            if tlist[0].text == "ERR_TIMED_OUT":
                raise InternalWebDriverRecoverableError("ERR_TIMED_OUT")
            elif tlist[0].text == "ERR_CONNECTION_CLOSED":
                raise InternalWebDriverRecoverableError("ERR_CONNECTION_CLOSED")
            elif tlist[0].text == "ERR_EMPTY_RESPONSE":
                raise InternalWebDriverRecoverableError("ERR_EMPTY_RESPONSE")
            elif tlist[0].text == "ERR_PROXY_CONNECTION_FAILED" and len(self._parent._proxyList) > 0:
                raise InternalWebDriverRecoverableError("ERR_PROXY_CONNECTION_FAILED")
            elif tlist[0].text == "ERR_TUNNEL_CONNECTION_FAILED" and len(self._parent._proxyList) > 0:
                raise InternalWebDriverRecoverableError("ERR_TUNNEL_CONNECTION_FAILED")
            else:
                # unrecoverable
                raise InternalWebDriverOtherError(tlist[0].text)

        # in fact you'll never be sure that the page is really loaded, so we do an extra wait
        ManpaUtil.waitRandomTime("middle")

    def __assertCheckBeforeDownload(self):
        assert len(os.listdir(self._parent._tmpDownloadDir)) == 0             # download directory should be empty

    def __waitForDownloadUrlOrFile(self, bAcceptUrl):
        assert self._curTabIndex == len(self.__getWindowHandles()) - 1        # we must be viewing the last tab

        ret = None

        # wait for download to start
        for i in ManpaUtil.sleepyZeroToMax(0, "1"):
            # wait file appears in download dir
            if len(os.listdir(self._parent._tmpDownloadDir)) > 0:
                break

            # some <a> tag opens a new tab for downloading, it should auto close after the download starts
            # but if the new tab keeps being in "complete" state and not auto-closed for 1 seconds, it means download fails, we need to refresh it
            if self._curTabIndex < len(self.__getWindowHandles()) - 1:
                assert self._curTabIndex == len(self.__getWindowHandles()) - 2
                self._driver.switch_to.window(self.__getWindowHandles()[-1])                  # browser's auto-switching-to-new-tab is not synced to selenium, so we need to switch manually
                if self._driver.execute_script("return document.readyState") == "complete":   # FIXME: there's a little chance that the new tab is auto-closed before this line, what to do?
                    ManpaUtil().waitRandomTime("1")
                    if self._curTabIndex < len(self.__getWindowHandles()) - 1:
                        assert self._curTabIndex == len(self.__getWindowHandles()) - 2
                        assert self._driver.execute_script("return document.readyState") == "complete"
                        self._driver.refresh()

            if i >= 10:
                self._parent._warnCallback("Download has not been started for %d seconds." % (i))

        # switch to download tab
        self._driver.switch_to.window(self.__getWindowHandles()[0])
        downloadManagerElement = self.__getDownloadManagerElement()

        # get download items
        downloadItemElement = None
        for i in ManpaUtil.sleepyZeroToMax(0, "1"):
            downloadItemElement = self.__getDownloadItemElement(downloadManagerElement)
            if downloadItemElement is not None:
                break
            if i >= 1:
                self._parent._warnCallback("Download item has not appeared for %d seconds." % (i))

        # get information from download tab
        url, filename = None, None
        if True:
            fileLink = self.__getDownloadItemElementSubComponent(downloadItemElement, "file-link")
            url = fileLink.get_attribute("href")
            assert url is not None and url != ""
            filename = self._driver.execute_script("return arguments[0].text", fileLink)                # fileLink.text gives me "", chromium is strange...
            assert filename is not None and filename != ""

        # get url: cancel download, delete download item
        while bAcceptUrl:
            # find cancel button
            cancelLink = self.__getDownloadItemElementSubComponent(downloadItemElement, "cancel-button")
            if cancelLink is None:
                # download finished, network is fast this time
                assert self.__getDownloadItemElementSubComponent(downloadItemElement, "show-in-folder-link") is not None
                break

            # click cancel button
            try:
                cancelLink.click()
            except selenium.common.exceptions.ElementNotInteractableException:
                # download finished before we are able to click the cancel button
                for i in ManpaUtil.sleepyZeroToMax(0, "1"):
                    if self.__getDownloadItemElementSubComponent(downloadItemElement, "show-in-folder-link") is not None:
                        break
                    self._parent._warnCallback("Cancel link in download tab exists but not clickable for %d seconds." % (i))
                break

            # wait for canceling to finish
            for i in ManpaUtil.sleepyZeroToMax(0, "1"):
                if len(os.listdir(self._parent._tmpDownloadDir)) == 0:
                    break
                if i >= 1:
                    self._parent._warnCallback("Downloading (%s) has not been cancelled for %d seconds." % (url, i))

            ret = (True, url, filename)
            break

        # or, get the already downloaded file
        if ret is None:
            fullfn = os.path.join(self._parent._tmpDownloadDir, filename)
            downloadTmpFullfn = fullfn + ".crdownload"

            # wait download to be finished
            for i in ManpaUtil.sleepyZeroToMax(0, "1"):
                if self.__getDownloadItemElementSubComponent(downloadItemElement, "show-in-folder-link") is not None:
                    break
                if i >= 3600 and i % 60 == 0:
                    self._parent._warnCallback("Downloading (%s) has not been finished for %d seconds." % (url, i))

            # renaming may not finish when the download item changes to complete state (we have encountered a 7 seconds delay)
            for i in ManpaUtil.sleepyZeroToMax(0, "1"):
                if not os.path.exists(downloadTmpFullfn):
                    break
                if i >= 10:
                    self._parent._warnCallback("Renaming not finished after download state change for %d seconds." % (i))

            if not os.path.exists(fullfn):
                print("debug")
                print(downloadTmpFullfn)
                print(os.listdir(self._parent._tmpDownloadDir))
            assert os.path.exists(fullfn)
            assert len(os.listdir(self._parent._tmpDownloadDir)) == 1

            ret = (False, fullfn)

        # remove download item
        self.__getDownloadItemElementSubComponent(downloadItemElement, "remove-button").click()
        for i in ManpaUtil.sleepyZeroToMax(0, "1"):
            if self.__getDownloadItemElement(downloadManagerElement) is None:
                break
            if i >= 1:
                self._parent._warnCallback("Download item has not disappeared after close for %d seconds." % (i))

        print("debug download number")
        print(len(self.__getDownloadItemElementList(downloadManagerElement, bWithHidden=True)))

        # close the not-auto-closed tab opened by some <a> tags, maybe due to download fail, maybe due to other reasons
        if self._curTabIndex < len(self.__getWindowHandles()) - 1:
            assert self._curTabIndex == len(self.__getWindowHandles()) - 2
            self._driver.switch_to.window(self.__getWindowHandles()[-1])
            self._driver.close()
            # we don't switch back to download tab here, because we will switch to the tab we are from finally

        # switch to the tab we are from
        assert len(self.__getWindowHandles()) == 2
        self._driver.switch_to.window(self.__getWindowHandles()[-1])

        # return result
        return ret

    def __getWindowHandles(self):
        while True:
            try:
                return self._driver.window_handles
            except selenium.common.exceptions.WebDriverException as e:
                # don't know why this happens even after STFW, maybe it is a chromium bug at least on version 111.0.5563.110
                if "disconnected: received Inspector.detached event" in e.msg:
                    self._parent._warnCallback("Inspector.detached event received when getting window handles.")
                    ManpaUtil.waitRandomTime("1")
                    continue
                raise

    def __getDownloadManagerElement(self):
        # we use javascript to select elements in download tab, webdriver.find_element is not valid here because download page uses shadow DOM
        ret = self._driver.execute_script("return document.querySelectorAll('downloads-manager')")
        if len(ret) > 0:
            # ensure only one download-manager element exists
            assert len(ret) == 1
            return ret[0]
        else:
            return None

    def __getDownloadItemElementList(self, downloadManagerElement, bWithHidden=False):
        ret = self._driver.execute_script("return arguments[0].shadowRoot.querySelectorAll('#downloadsList downloads-item')", downloadManagerElement)
        if not bWithHidden:
            ret = [x for x in ret if x.is_displayed()]
        return ret

    def __getDownloadItemElement(self, downloadManagerElement):
        ret = self._driver.execute_script("return arguments[0].shadowRoot.querySelectorAll('#downloadsList downloads-item')", downloadManagerElement)
        ret = [x for x in ret if x.is_displayed()]
        if len(ret) > 0:
            assert len(ret) == 1    # ensure only one download-item element exists
            return ret[0]
        return None

    def __getDownloadItemElementSubComponent(self, downloadItemElement, componentType):
        if componentType == "file-link":
            return self._driver.execute_script("return arguments[0].shadowRoot.querySelector('div#content a#file-link')", downloadItemElement)
        elif componentType == "cancel-button":
            return self._driver.execute_script("return arguments[0].shadowRoot.querySelector('cr-button[focus-type=\"cancel\"]')", downloadItemElement)
        elif componentType == "show-in-folder-link":
            return self._driver.execute_script("return arguments[0].shadowRoot.querySelector('a[id=\"show\"]')", downloadItemElement)
        elif componentType == "remove-button":
            return self._driver.execute_script("return arguments[0].shadowRoot.querySelector('cr-icon-button[id=\"remove\"]')", downloadItemElement)
        else:
            assert False

    def __getattr__(self, attr):
        if attr in ["get", "switch_to", "window_handles"]:
            raise AttributeError(f"'{self.__class__.name}' object has no attribute '{attr}'")       # ugly, from PEP-562, a string which is same as system error message
        return getattr(self._driver, attr)

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        self.dispose()


class ManpaSeleniumWebElement:

    def __init__(self, elem):
        self._elem = elem

    def mark_identified(self, message=None):
        # FIXME
        return

    def mark_error(self, message=None):
        # FIXME
        return

    def __getattr__(self, attr):
        return getattr(self._elem, attr)


class ManpaSeleniumRecordThread(threading.Thread):

    def __init__(self, driver, videoLogFile):
        self._driver = driver
        self._videoLogFile = videoLogFile
        self._fps = 30
        self._bStop = False

    def run(self):
        out = None
        if True:
            firstFrame = self._captureFrame()

            height, width, _ = firstFrame.shape
            frameSize = (width, height)

            fourcc = cv2.VideoWriter_fourcc(*'mp4v')
            out = cv2.VideoWriter(self._videoLogFile, fourcc, self._fps, frameSize)

            out.write(firstFrame)
            del firstFrame              # release memory

        while not self._bStop:
            startTime = time.time()

            frame = self._captureFrame()
            out.write(frame)

            elapsed = time.time() - startTime
            if elapsed < 1 / self._fps:
                time.sleep(1 / self._fps - elapsed)

        out.relase()

    def stop(self):
        assert not self._bStop
        self._bStop = True
        self.join()

    def _captureFrame(self):
        screenshot = self._driver.get_screenshot_as_png()
        img = PIL.Image.open(io.BytesIO(screenshot))
        return cv2.cvtColor(numpy.array(img), cv2.COLOR_RGBA2RGB)


class InternalWebDriverRecoverableError(Exception):
    pass


class InternalWebDriverOtherError(Exception):
    pass
